Dalvik 下和 ART 下的dex 加载流程及通用脱壳
DEX 加载流程概述
要理解脱壳技术,首先必须深入理解 Android 虚拟机加载和执行 DEX 字节码的完整流程。Android 经历了从 Dalvik 虚拟机到 ART(Android Runtime)虚拟机的重大架构变更,两者在 DEX 加载和执行的机制上存在显著差异。掌握这些差异,是理解各类脱壳方案设计思路的基础。
本文将分别详细分析 Dalvik 和 ART 下 DEX 的加载流程,然后介绍通用脱壳思路,最后对比 FART 与经典脱壳方案 DexHunter 的区别。
Dalvik 虚拟机的 DEX 加载流程
Dalvik 虚拟机是 Android 4.4(KitKat)及之前版本使用的运行时环境。它采用基于寄存器的指令集架构,以 JIT(即时编译)方式执行 DEX 字节码。
加载入口:PathClassLoader 与 DexClassLoader
当应用需要加载一个 DEX 文件时,无论是通过 PathClassLoader(加载系统应用和已安装应用)还是 DexClassLoader(动态加载外部 DEX),最终都会调用到 Dalvik 虚拟机的原生层方法。
在 Dalvik 的源码中,Java 层的 DexPathList 会将 DEX 路径传递给 openDexFile 方法,这个方法会执行以下核心流程:
核心加载步骤
1. openDexFile —— 打开 DEX 文件
openDexFile 是整个 DEX 加载的入口函数,定义在 dalvik/vm/DvmDex.cpp 中。它负责将 DEX 文件从磁盘映射到内存:
// dalvik/vm/DvmDex.cpp
DvmDex* dvmDexFileOpenFromFd(int fd, const char* fileName) {
// 将 DEX 文件 mmap 到内存
MemMapping map;
if (sysMapFileInShmemWritableReadOnly(fd, &map) != 0) {
return NULL;
}
// 校验 DEX 文件 magic 头
if (!dvmCheckDexHeader(map.addr, map.length)) {
return NULL;
}
// 解析 DEX 文件结构
return dvmDexFileOpen(map.addr, map.length, false, fileName);
}
此阶段完成后,DEX 文件的原始字节被完整映射到内存中,此时内存中已经可以获取到完整的 DEX 数据。这是内存 dump 类脱壳方案的关键时机之一。
2. defineClass —— 定义类
defineClass 负责在虚拟机中注册一个 Java 类。它会解析 DEX 文件中的类定义结构(DexClassDef),为每个类创建虚拟机内部的数据结构 ClassObject:
// dalvik/vm/oo/Class.cpp
ClassObject* dvmDefineClass(const char* descriptor, Object* loader, DexFile* pDexFile) {
// 在 DEX 中查找类定义
const DexClassDef* pClassDef = dexFindClass(pDexFile, descriptor);
if (pClassDef == NULL) return NULL;
// 分配 ClassObject
ClassObject* newClass = dvmAllocClass(DvmObject_allocClass(loader, ...));
// 解析类的字段、方法、接口等
dvmLoadClassFields(newClass, pDexFile, pClassDef);
dvmLoadClassMethods(newClass, pDexFile, pClassDef);
loadClassFromDex(newClass, pDexFile, pClassDef);
return newClass;
}
在这个阶段,虚拟机会读取类的方法列表(包括方法的 code_item 偏移量),但此时方法的字节码还没有被执行,只是注册了方法的结构信息。
3. linkClass —— 链接类
linkClass 是类加载的最后一步,负责完成类的链接工作,包括:
- 验证(Verification):检查字节码的合法性
- 准备(Preparation):分配静态字段的内存
- 解析(Resolution):解析类之间的引用关系
// dalvik/vm/oo/Class.cpp
bool dvmLinkClass(ClassObject* clazz) {
// 验证字节码
if (!dvmVerifyClass(clazz)) return false;
// 准备静态字段
if (!dvmPrepareClass(clazz)) return false;
// 解析引用
if (!dvmResolveClass(clazz)) return false;
// 标记为已链接
clazz->status = CLASS_LINKED;
return true;
}
在 Dalvik 下,DEX 完整加载到内存后,可以直接通过 /proc/self/maps 找到 mmap 的 DEX 内存区域,然后 dump 出完整的 DEX 文件。 这是由于 Dalvik 的 DEX 加载是整体映射的,不存在代码抽离的情况(除非是抽取壳做了额外处理)。
ART 虚拟机的 DEX 加载流程
从 Android 5.0(Lollipop)开始,ART 完全取代了 Dalvik。ART 采用 AOT(Ahead-of-Time)编译策略,在应用安装时将 DEX 字节码编译为机器码。ART 的 DEX 加载流程比 Dalvik 复杂得多。
AOT 与 JIT 的双重机制
ART 在不同版本中采用了不同的编译策略:
- Android 5.0-6.0:纯 AOT 编译,安装时将所有 DEX 编译为 OAT 文件
- Android 7.0+:AOT + JIT 混合模式,首次运行使用 JIT + profile 引导编译
- Android 10+:进一步优化,引入 baseline profile 等机制
核心加载步骤
1. OpenDexFilesFromOat —— 从 OAT 加载 DEX
ART 的 DEX 加载入口与 Dalvik 有本质区别。在 ART 中,DEX 文件通常已经被编译为 OAT 文件,加载过程优先从 OAT 文件中获取编译后的机器码:
// art/runtime/oat_file_manager.cc
std::vector<std::unique_ptr<const DexFile>>
OatFileManager::OpenDexFilesFromOat(const std::string& dex_location,
const OatFile::OatDexFile** oat_dex_files,
bool* out_oat_vdex_equals_dex_vdex) {
// 1. 尝试查找对应的 OAT 文件
const OatFile* oat_file = FindOatFile(dex_location);
// 2. 从 OAT 文件中提取嵌入的 DEX
if (oat_file != nullptr) {
oat_file->GetDexFiles(oat_dex_files);
}
// 3. 如果没有 OAT,则直接打开 DEX 文件
return OpenDexFiles(dex_location);
}
2. LoadClass —— 加载类
ART 的 LoadClass 对应 Dalvik 的 defineClass,负责解析类定义:
// art/runtime/class_linker.cc
class ClassLinker {
void LoadClass(Thread* self, const DexFile& dex_file,
const DexFile::ClassDef& class_def,
Handle<mirror::Class> klass) {
// 加载静态字段
LoadField(self, klass, ...);
// 加载实例字段
LoadField(self, klass, ...);
// 加载方法(包括 native 方法和直接方法)
LoadMethod(self, klass, ...);
}
};
关键区别:ART 在 LoadClass 阶段会将方法的入口点(ArtMethod::entry_point_)设置为 OAT 中编译好的机器码地址。对于尚未编译的方法,则设置为 art_quick_to_interpreter_bridge(解释执行入口)。
3. LinkClass —— 链接类
ART 的 LinkClass 与 Dalvik 类似,但更加复杂:
// art/runtime/class_linker.cc
void ClassLinker::LinkClass(Thread* self,
Handle<mirror::Class> klass,
Handle<mirror::ObjectArray<mirror::Class>> interfaces) {
// 验证类
if (!VerifyClass(self, klass)) return;
// 分配静态字段内存
InitializeStaticFields(self, klass);
// 解析方法和接口的引用
LinkMethods(self, klass);
// 设置类状态
klass->SetStatus(ClassStatus::kLinked);
}
ART 下 DEX 内存布局的特点
ART 在加载 DEX 时,DEX 数据并不是以原始文件的形式直接 mmap 的。经过 OAT 编译后,DEX 数据被嵌入到 OAT 文件中,并且可能经过重定位处理。因此:
/proc/self/maps中看到的是 OAT 文件的映射区域,不是纯粹的 DEX- 内存中的 DEX 数据可能不连续,分散在 OAT 文件的不同位置
- DEX 头部的
class_defs偏移量和code_item偏移量可能已经被修改
这些特点使得在 ART 下直接 dump DEX 变得更加困难。
Dalvik 与 ART 的关键差异
| 对比维度 | Dalvik | ART |
|---|---|---|
| 执行方式 | JIT 即时编译 | AOT + JIT 混合编译 |
| DEX 加载 | 直接 mmap DEX 文件 | 从 OAT 中提取嵌入的 DEX |
| 方法执行 | 解释执行 Dalvik 字节码 | 执行编译后的 ARM 机器码 |
| 内存中的 DEX | 完整连续的 DEX 映射 | 可能分散在 OAT 文件中 |
| dump 难度 | 相对简单 | 相对复杂 |
| 脱壳时机 | openDexFile 之后即可 | 需要在特定阶段获取完整 DEX |
通用脱壳思路:内存 Dump 方案
无论在 Dalvik 还是 ART 下,脱壳的核心思想都是相同的:在 DEX 被完整解密并加载到内存中的某个时刻,将内存中的 DEX 数据 dump 出来。
内存 Dump 的基本步骤
-
寻找 DEX 在内存中的位置:通过扫描
/proc/self/maps,查找具有 DEX 特征的内存区域。DEX 文件的特征是其开头 8 字节的 magic 值:dex\n035\0或dex\n037\0、dex\n038\0等。 -
提取 DEX 数据:找到包含 DEX 的内存区域后,从 DEX 头部记录的
file_size字段读取 DEX 的完整大小,然后将对应的内存区域 dump 出来。 -
修复 DEX 文件:由于内存中的 DEX 可能被修改(特别是抽取壳会将
code_item清零),需要额外的修复步骤来还原完整的 DEX。
// 内存扫描 DEX 的伪代码
void scanAndDumpDex() {
FILE* maps = fopen("/proc/self/maps", "r");
char line[256];
while (fgets(line, sizeof(line), maps)) {
// 查找可读的匿名映射区域
if (strstr(line, "r--p") && !strstr(line, ".oat")) {
sscanf(line, "%lx-%lx", &start, &end);
// 在这块内存中搜索 DEX magic
for (addr = start; addr < end - 8; addr++) {
if (memcmp(addr, "dex\n035", 8) == 0 ||
memcmp(addr, "dex\n036", 8) == 0) {
// 找到 DEX,读取 file_size 并 dump
dumpDex(addr);
}
}
}
}
fclose(maps);
}
Dalvik 下的内存 Dump
在 Dalvik 下,由于 DEX 是整体 mmap 的,内存 dump 相对直接。壳程序解密 DEX 后通过 DexClassLoader 加载时,DEX 会被 mmap 到内存中,此时直接扫描并 dump 即可得到完整的 DEX 文件。
ART 下的内存 Dump
在 ART 下,DEX 数据可能在 OAT 文件的映射区域中,也可能在 dex2oat 编译过程中产生的临时映射中。需要更精细的判断来识别有效的 DEX 区域。
DexHunter 方案原理
DexHunter 是早期针对 ART 的经典脱壳方案,其核心思路是:
设计原理
DexHunter 通过修改 ART 虚拟机的源码,在 LoadClass 阶段对每个类的方法进行检查。如果一个方法的 code_item 指针为空(被抽取壳清零),则记录这个类;如果所有类的 code_item 都不为空,则说明 DEX 是完整的,直接 dump。
工作流程
1. 修改 art/runtime/class_linker.cc 的 LoadClass 方法
2. 遍历类中的所有方法
3. 检查 ArtMethod->GetCodeItem() 是否为空
4. 如果为空,说明该方法的 code_item 被抽取
5. 在所有类加载完成后,dump 当前内存中的 DEX
DexHunter 的局限性
- 依赖源码修改:需要编译自定义的 Android 系统镜像,部署成本高
- 时序问题:在
LoadClass阶段 dump,此时被抽取的方法代码还没有被回填 - 被动等待:不能主动触发方法的执行,依赖于应用自身运行过程中加载类
FART 与 DexHunter 的区别
FART(First Android Unpacking Tool)是基于 DexHunter 思路的增强版本,两者有本质的设计差异:
| 对比维度 | DexHunter | FART |
|---|---|---|
| 核心思路 | 被动等待类加载 | 主动调用所有方法 |
| dump 时机 | LoadClass 阶段 | LinkClass 之后,主动调用前 |
| 抽取壳应对 | dump 的 DEX 中 code_item 为空 | 主动调用后 code_item 已回填,dump 完整 |
| DEX 修复 | 不支持 | 内置 dex 修复功能 |
| 方法覆盖 | 只能处理已加载的类 | 可以触发所有方法的加载和回填 |
| 信息收集 | 不收集方法指令 | 收集 dex_method_insns 便于分析 |
FART 的最大创新在于主动调用机制:在 DEX 加载完成后、类链接完成后,主动调用 DEX 中每个类的方法,迫使壳程序在方法执行前将抽取的 code_item 回填到内存中。然后在所有方法调用完毕后,再进行 DEX 的 dump,此时内存中的 DEX 已经是完整的。
总结
理解 DEX 在 Dalvik 和 ART 下的加载流程,是脱壳技术的基石。Dalvik 下的 DEX 加载相对简单直接,而 ART 下的加载涉及 OAT 编译和复杂的内存布局。通用脱壳的核心思路是内存 dump,但在抽取壳的场景下,关键在于选择正确的 dump 时机。DexHunter 开创了 ART 下脱壳的先河,而 FART 通过主动调用机制实现了抽取壳的全面突破。在后续文章中,我们将深入分析 FART 的组件设计和源码实现细节。